--[[## ft=lua ##################################################################
  This file is licensed under [WTFPL v2](https://www.wtfpl.net/).  You may read
  the full license in the file LICENSE or by following the web link.

  POBJECT - simple prototype-based object system for Lua
--############################################################################]]

--[[####################################
  Exception objects with printable forms.
--####################################]]

-- build throwable errors with extra information available
local function build_exception(string_representation, ...)
    return setmetatable({...}, { __tostring = function() return string_representation end })
end

-- exceptions thrown by this library
local ERR_ARRAY_INITIALIZER    = build_exception('initializers cannot be array-flavoured tables')
local ERR_DESTROYED            = build_exception('pobject destroyed')
local ERR_FIXED                = build_exception('cannot change fixed object')
local ERR_FUNDAMENTAL          = build_exception('cannot override fundamental operations')
local ERR_NONTABLE_INITIALIZER = build_exception('initializers must be be in a table')

--[[####################################
  Module-scope registry variables.
--####################################]]

-- foward declaration for protected fundamental operations
local fundamental_operations = {}

-- storage for cloned local values
local values = {}

-- weak-keyed storage for children
local children = setmetatable({}, { __mode = "k" })

-- weak-keyed storage for observers
local observers = setmetatable({}, { __mode = "k" })

--[[####################################
  Local helper functions.
--####################################]]

-- shallow table merge
local function merge(d, s)
    if type(d) == 'table' and type(s) == 'table' then
        for k,v in pairs(s) do
            d[k] = v
        end
    end
    return d
end

-- shallow clone of a table of values
local function clone(s)
    return merge({}, s)
end

-- check possible parental relationship between two objects
local function check_relationship(child, parent_prospect)
    -- start with the immediate parent
    local current_ancestor = values[child].parent

    while current_ancestor ~= nil do
        if current_ancestor == parent_prospect then
            return true
        end
        current_ancestor = values[current_ancestor] and values[current_ancestor].parent
    end

    return false
end

local function build_metatable(has_parent)
    return {
        __index = function(t, k)
            if fundamental_operations and fundamental_operations[k] then
                return fundamental_operations[k]
            elseif values[t] and values[t].locals and values[t].locals[k] then
                return values[t].locals[k]
            elseif has_parent and values[t] and values[t].parent and values[t].parent[k] then
                return values[t].parent[k]
            else
                return nil
            end
        end,

        __newindex = function(t, k, v)
            assert(fundamental_operations[k] == nil, ERR_FUNDAMENTAL)
            values[t].locals[k] = v
        end,

        __type = "pobject"
    }
end

--[[####################################
  Module initializer function.
--####################################]]

local function new(_module, base_table)
    assert(base_table == nil or type(base_table) == 'table', ERR_NONTABLE_INITIALIZER)
    assert(base_table == nil or #base_table == 0, ERR_ARRAY_INITIALIZER)

    local id = {}
    values[id] = {
        locals = clone(base_table) or {},
        parent = {},
    }

    return setmetatable(id, build_metatable(false))
end

--[[####################################
  pobject methods.
--####################################]]

-- make a child by cloning a parent, adding extra/changed members optionally
fundamental_operations.clone = function(parent, members)
    local id = {}
    values[id] = {
        locals = clone(members) or {},
        parent = parent or {},
    }

    children[parent] = children[parent] or {}
    table.insert(children[parent], id)

    return setmetatable(id, build_metatable(true))
end

-- mix in a package of operations and/or values
fundamental_operations.using = function(self, mixin)
    values[self].locals = merge(values[self].locals, mixin)
    return self
end

-- make a pobject read-only
-- note that if the parent is not fixed, non-overridden members can be changed
-- and new keys added
fundamental_operations.fix = function(self)
    mt = getmetatable(self)
    mt.__newindex = function() error(ERR_FIXED) end
    return setmetatable(self, mt)
end

-- re-root an object into an independent prototype with a flattened membership
-- tree
fundamental_operations.sever = function(self)
    -- start with our immediate parent
    local current_ancestor = values[self].parent

    -- merge ancestral values
    merge_values = {}
    while current_ancestor ~= nil do
        -- if our parent has values, clone them and merge them down
        if values[current_ancestor] and values[current_ancestor].locals then
            ancestor_clone = clone(values[current_ancestor].locals or {})
            merge_values = merge(ancestor_clone, merge_values)
        end
        -- get our next ancestor
        current_ancestor = values[current_ancestor] and values[current_ancestor].parent
    end

    -- finally merge all ancestors into current locals
    merge_values = merge(merge_values, values[self].locals or {})
    values[self].locals = merge_values

    -- now emancipate ourselves from our family
    values[self].parent = {}
    return setmetatable(self, build_metatable(false))
end

-- destroy an object, ensuring its resources are freed
fundamental_operations.destroy = function(self)
    if children[self] ~= nil then
        for _,v in ipairs(children[self]) do
            v:sever()
        end
        children[self] = nil
    end
    values[self].locals = nil
    values[self].parent = nil
    values[self] = nil
    local mt = getmetatable(self)
    mt.__index = function(t,k) error(ERR_DESTROYED, 2) end
    mt.__newindex = function(t,k,v) error(ERR_DESTROYED, 2) end
end

-- provide a tabular portrayal of members in the ancestor tree
-- provided table can be printed in readable form
fundamental_operations.introspect = function(self)
    -- build an indent block
    local function indent(n)
        return string.rep(' ', n)
    end

    -- walk the chain of ancestors
    local function ancestors()
        local parent = values[self].parent
        if parent ~= nil and parent.introspect ~= nil then
            return parent:introspect()
        else
            return nil
        end
    end

    -- provide a pretty string representation of an object's entire inheritance hierarchy
    local seen = {}
    local function to_string(t, i)
        if t == nil then return '' end

        local ii = i or 0

        local str = indent(ii) .. (ii == 0 and 'object' or 'parent') .. (string.format('<%p> = \n', t))
        if seen[t] then
            str = str .. ('{ ... <circular> ... }\n')
        else
            seen[t] = true
            str = str .. indent(ii) .. '{\n'
            for k,v in pairs(t.locals) do
                str = str .. indent(ii + 2) .. tostring(k) .. ' -> ' .. tostring(v) .. '\n'
            end
            str = str .. to_string(t.parent, ii + 2)
            str = str .. indent(ii) .. '}\n'
        end
        if i == nil then
            seen = {}
        end

        return str
    end

    local rv = setmetatable({}, { __tostring = to_string })
    rv.locals = clone(values[self].locals)
    rv.parent = ancestors()

    return rv
end

-- predicate: is self a descendant of the candidate object
fundamental_operations.descendant_of = function(self, candidate)
    return check_relationship(self, candidate)
end

-- predicate: is self an ancestor of the candidate object
fundamental_operations.ancestor_of = function(self, candidate)
    return check_relationship(candidate, self)
end

-- attach access observers to properties/methods and pre/post wrappers to methods
fundamental_operations.observe = function(self, config)
    observers[self] = observers[self] or {}
    observers[self].props = observers[self].props or {}
    observers[self].methods = observers[self].methods or {}
    observers[self].props = merge(observers[self].props, config and config.props or {})
    observers[self].methods = merge(observers[self].methods, config and config.methods or {})

    -- Only patch metatable once
    if not getmetatable(self).__observed then
        local mt = getmetatable(self)
        local old_index = mt.__index
        local old_newindex = mt.__newindex

        -- Property access
        mt.__newindex = function(t, key, value)
            local obs = observers[t]
            if obs and obs.props[key] and obs.props[key].set then
                obs.props[key].set(self, key, value)
            end
            old_newindex(t, key, value)
        end

        -- Method/Property lookup
        mt.__index = function(t, key)
            local value = old_index(t, key)

            -- Method wrapping
            if observers[t] and observers[t].methods[key] and type(value) == "function" then
                local method = value
                value = function(...)
                    local pre = observers[t].methods[key].pre
                    local post = observers[t].methods[key].post
                    if pre then pre(key, ...) end
                    local results = { method(...) }
                    if post then post(key, ...) end
                    return table.unpack(results)
                end
            -- Property access
            elseif observers[t] and observers[t].props[key] and observers[t].props[key].get then
                observers[t].props[key].get(self, key, value)
            end

            return value
        end

        mt.__observed = true -- Mark as patched
        setmetatable(self, mt)
    end

    return self
end

--[[####################################
  # Module return is an empty table with a __call override pointing to the constructor.
  ####################################]]

return setmetatable({}, { __call = new })
